Skip to content

Add configurable rebalance strategy for bridge pegout#934

Open
volodymyrzahanych wants to merge 4 commits intov2.6.0from
feature/FLY-2235
Open

Add configurable rebalance strategy for bridge pegout#934
volodymyrzahanych wants to merge 4 commits intov2.6.0from
feature/FLY-2235

Conversation

@volodymyrzahanych
Copy link
Collaborator

What

Add configurable rebalance strategy for bridge pegout, supporting two modes: ALL_AT_ONCE (existing behavior - converts entire RSK balance to BTC in a single transaction) and UTXO_SPLIT (splits the pegout amount into multiple UTXOs based on the provider's min/max pegout limits for better liquidity distribution)

Why

The current rebalance behavior sends all funds waiting for rebalance in a single native pegout transaction, causing UTXOs to converge into one during usage spikes
When all liquidity is consumed, only one large native pegout is executed, reducing UTXO availability for subsequent operations

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change
  • Documentation update
  • Refactoring (no functional changes, no api changes)
  • Performance improvement
  • Test updates
  • Security fix
  • Deployment/Infrastructure changes

Affected part of the project

  • Management UI / API
  • PegIn flow
  • PegOut flow
  • Utility scripts
  • Configuration files
  • Metrics and alerting

Related Issues

Jira ticket

How to test

Set REBALANCE_STRATEGY=UTXO_SPLIT, initiate a large pegout (e.g. 3 RBTC), wait for rebalance, and verify the LP BTC wallet receives multiple UTXOs of ~BridgeTransactionMin size instead of one large UTXO
Set REBALANCE_STRATEGY=ALL_AT_ONCE, repeat the same flow, and verify the LP BTC wallet receives a single UTXO with the full amount
Run unit tests: go test ./internal/usecases/pegout/ -run TestBridgePegout

Reviewer Guidelines

  • Verify the splitting math: firstChunk + (N-1)×bridgeMin = totalValue
  • Review partial failure handling — acceptable trade-off?
  • Check if updateQuotes storing only last tx hash needs a follow-up ticket
  • Confirm BridgeTransactionMin in management API matches the Bridge's actual minimum
  • Verify the 66-tx scenario (100 RBTC test case) is acceptable for production Bridge load
  • Decide if discarded error from Div() should use explicit _ or add a comment
  • Test locally with REBALANCE_STRATEGY=UTXO_SPLIT in .env.regtest and run pegout-cycle.sh full-cycle --repeat 3 + check-btc-release

Screenshots (if applicable)

Add screenshots or GIFs to help explain your changes.

Additional Notes

AI Code Review: Configurable Rebalance Strategy for Bridge Pegout

Commit: 5cea8c2 — "Add configurable rebalance strategy for bridge pegout"
Branch: feature/FLY-2235
Reviewer: Claude Opus 4.6 (AI-assisted review)
Date: 2026-03-13


1. Summary

This feature introduces a configurable rebalance strategy for the bridge pegout flow. Previously, the LP always sent the entire accumulated refund total to the RSK Bridge in a single transaction (ALL_AT_ONCE). The new UTXO_SPLIT strategy splits the bridge conversion into multiple transactions of BridgeTransactionMin size each, producing multiple UTXOs on the BTC side.

Motivation: When the Bridge releases BTC back to the LP, each bridge transaction produces one UTXO. With UTXO_SPLIT, the LP receives N smaller UTXOs instead of one large one, improving UTXO management for future pegout operations.


2. Files Changed

File Change
internal/usecases/pegout/bridge_pegout.go Core logic: RebalanceStrategy type, ParseRebalanceStrategy(), runAllAtOnce(), runUtxoSplit()
internal/usecases/pegout/bridge_pegout_test.go 6 UTXO_SPLIT tests + 8-case amount integrity test
internal/configuration/environment/environment.go Added RebalanceStrategy field to PegoutEnv
internal/configuration/environment/environment_reader_test.go Test env setup
internal/configuration/registry/usecase.go Parse strategy at startup, pass to BridgePegoutUseCase
internal/configuration/registry/usecase_test.go Test setup
internal/configuration/registry/watcher_test.go Test setup
internal/adapters/entrypoints/watcher/pegout_bridge_watcher_test.go Updated constructor call
cmd/application/lps/application.go Handle NewUseCaseRegistry returning (*UseCaseRegistry, error)
docker-compose/local/docker-compose.lps.yml Added REBALANCE_STRATEGY to container env
sample-config.env Default: REBALANCE_STRATEGY=ALL_AT_ONCE

3. Architecture & Data Flow

Environment                    Registry                       Use Case
─────────────────────────────────────────────────────────────────────────
REBALANCE_STRATEGY env var
        │
        ▼
PegoutEnv.RebalanceStrategy
  (validated: required,
   oneof=ALL_AT_ONCE
         UTXO_SPLIT)
        │
        ▼
                         ParseRebalanceStrategy()
                               │
                               ▼
                         NewBridgePegoutUseCase(..., strategy)
                               │
                               ▼
                         BridgePegoutUseCase.strategy (immutable field)
                               │
                               ▼
              ┌────────────────┴────────────────┐
              │                                 │
        ALL_AT_ONCE                       UTXO_SPLIT
              │                                 │
              ▼                                 ▼
     runAllAtOnce()                    runUtxoSplit()
     1 × SendRbtc(total)              N × SendRbtc(bridgeMin)
                                       (first chunk = bridgeMin + remainder)

Trigger Path

  1. PegoutBridgeWatcher ticks every 5 minutes
  2. Queries quotes in RefundPegOutSucceeded state
  3. Calls BridgePegoutUseCase.Run(ctx, quotes...)
  4. Run() calculates totalValue = Σ(Value + CallFee + GasFee) for all quotes
  5. If totalValue < BridgeTransactionMin → skip (normal, logged as info)
  6. Acquires rskWalletMutex
  7. Delegates to runAllAtOnce() or runUtxoSplit() based on strategy

4. Splitting Algorithm (UTXO_SPLIT)

Input:  totalValue, bridgeMin (from PegoutConfiguration)

numTxs    = totalValue / bridgeMin          (integer division, truncated)
remainder = totalValue - (numTxs × bridgeMin)

Invariant: numTxs × bridgeMin + remainder = totalValue (exact, no rounding)

If N = 1:
    Send 1 tx of totalValue (no split needed)

If N ≥ 2:
    Tx 1:     bridgeMin + remainder    (first chunk absorbs the remainder)
    Tx 2..N:  bridgeMin each

Sum = (bridgeMin + remainder) + (N-1) × bridgeMin
    = N × bridgeMin + remainder
    = totalValue                       ✓ exact

Why this is rounding-safe: All arithmetic uses Go's math/big.Int (arbitrary precision integers). The remainder is computed by subtraction (total - N×min) rather than modulo, so firstChunk + (N-1)×min = total is an algebraic identity, not an approximation.

Gas Calculation

gasPerTx        = BridgeConversionGasLimit × BridgeConversionGasPrice = 100000 × 60000000 = 6×10¹² wei
requiredBalance = totalValue + N × gasPerTx

For ALL_AT_ONCE, gas is 1 × gasPerTx. For UTXO_SPLIT with N transactions, gas scales linearly as N × gasPerTx.


5. Code Review Findings

5.1 Correctness

# Item Status Notes
1 Integer division produces correct N big.Int.Div truncates toward zero
2 Remainder computation is exact remainder = total - N×min (subtraction, not modulo)
3 Sum of all sent amounts = totalValue Algebraic identity, verified by TestUtxoSplit_AmountIntegrity
4 Each split tx ≥ bridgeMin First chunk = min + remainder ≥ min; rest = min
5 N=1 sends totalValue (not bridgeMin) Line 148: config := blockchain.NewTransactionConfig(totalValue, ...)
6 Balance check accounts for N×gas Line 137: requiredBalance = totalValue + N×gasPerTx
7 bridgeMin.Copy() prevents mutation Lines 161, 178 use .Copy() before passing to tx config
8 Strategy dispatch uses switch/default ALL_AT_ONCE is the default (line 94)

5.2 Error Handling

# Scenario Behavior Assessment
1 Quotes not all refunded Error returned early, no bridge tx ✅ correct
2 Total below bridgeMin TxBelowMinimumError, logged as info by watcher ✅ correct
3 Wallet balance error Error returned, quotes not updated ✅ correct
4 Insufficient balance InsufficientAmountError returned ✅ correct
5 First tx fails (N≥2) Quotes marked BridgeTxFailed, error returned ✅ correct
6 Tx K fails mid-split (K>1) Loop breaks, quotes marked failed using last receipt ⚠️ see 5.3
7 UpdateRetainedQuotes fails Error returned (joined with tx error if any) ✅ correct

5.3 Design Observations for Peer Discussion

A. Partial Success Handling in UTXO_SPLIT

When N≥2 and tx K fails (K>1), transactions 1..K-1 have already been sent to the bridge and cannot be rolled back. The code marks all quotes as BridgeTxFailed:

// bridge_pegout.go:177-185
for i := uint64(1); i < n; i++ {
    config = blockchain.NewTransactionConfig(bridgeMin.Copy(), ...)
    receipt, txErr = useCase.rskWallet.SendRbtc(ctx, config, bridgeAddress)
    if txErr == nil {
        log.Debugf(...)
    } else {
        break
    }
}
err = useCase.updateQuotes(ctx, receipt, txErr, watchedQuotes)

What happens:

  • RBTC from successful txs has already been sent to the Bridge — those funds are in transit and will eventually produce BTC UTXOs
  • But all quotes are marked BridgeTxFailed, so the watcher won't re-process them
  • The Bridge will still release BTC for the successful txs (bridge is stateless w.r.t. LPS quote tracking)

Assessment: This is a conservative design choice — it prevents double-sending by marking all quotes as terminal. The LP receives the partial BTC but the accounting is pessimistic. An operator would need to reconcile manually.

Alternative (not necessarily better): Track per-tx success and mark quotes proportionally. But this adds complexity and partial state management that may not be worth it given the rarity of mid-split failures.

B. updateQuotes Uses Last Receipt Only

In the split loop, updateQuotes is called once with the final receipt:

// bridge_pegout.go:187
err = useCase.updateQuotes(ctx, receipt, txErr, watchedQuotes)

On success (all N txs complete), receipt is from the last tx. This means BridgeRefundTxHash on all quotes points to tx N, not tx 1. For ALL_AT_ONCE this is fine (one tx). For UTXO_SPLIT, it means quote records reference only the last bridge tx hash.

Impact: The other tx hashes are logged (debug level) but not persisted in the database. If an operator needs to audit all bridge transactions for a rebalance cycle, they'd need to check logs or chain state.

C. numTxs Returned as *Wei From Division

numTxs, _ := new(entities.Wei).Div(totalValue, bridgeMin)

The error from Div is discarded. This is safe because bridgeMin is validated upstream (it passes the BridgeTransactionMin.Cmp(totalValue) > 0 check at line 79, confirming bridgeMin > 0), so division by zero cannot occur. However, the discarded error is a minor code smell.

D. Default Strategy is ALL_AT_ONCE

The switch statement defaults to runAllAtOnce:

switch useCase.strategy {
case UtxoSplit:
    return useCase.runUtxoSplit(...)
default:
    return useCase.runAllAtOnce(...)
}

This is safe: even if a new strategy value were somehow injected, the system falls back to the original single-tx behavior. The double validation (env + parsing) prevents this in practice.


6. Test Coverage Assessment

Existing Tests (from the cherry-picked commit)

Test Strategy Scenario Verifies
testBridgePegoutUseCaseSuccess ALL_AT_ONCE Happy path, total=558 Exact amount, receipt fields, state transition
testBridgePegoutUseCaseValueBelowMinimum ALL_AT_ONCE total < min No wallet calls, correct error
testBridgePegoutUseCaseQuotesNotRefunded ALL_AT_ONCE Mixed quote states Early rejection
testBridgePegoutUseCaseWalletBalanceError ALL_AT_ONCE GetBalance error Error propagated
testBridgePegoutUseCaseWalletWithoutBalance ALL_AT_ONCE balance < required InsufficientAmountError
testBridgePegoutUseCaseTxFails ALL_AT_ONCE SendRbtc error Quotes marked BridgeTxFailed
testBridgePegoutUseCaseUpdateFails ALL_AT_ONCE DB update error Error returned
testUtxoSplitSuccess UTXO_SPLIT N=2, R=158 Tx amounts: 358+200
testUtxoSplitNoSplitWhenN1 UTXO_SPLIT N=1 Single tx of totalValue
testUtxoSplitBelowMinimum UTXO_SPLIT total < min Rejected
testUtxoSplitExactMultiple UTXO_SPLIT N=3, R=0 Three equal txs of 200
testUtxoSplitFailMidSplit UTXO_SPLIT N=2, tx2 fails All quotes marked failed
testUtxoSplitInsufficientGas UTXO_SPLIT N=2, gas too low InsufficientAmountError
TestParseRebalanceStrategy Parsing Valid/invalid strategy strings

Added Tests (on this branch)

Test Scenario Key Assertion
TestUtxoSplit_AmountIntegrity (8 sub-tests) Various N and remainders at wei scale sum(all sent amounts) == totalValue exactly + each amount ≥ bridgeMin

Cases in the integrity test:

Sub-test Total Min N Key Verification
total == min 1.5 RBTC 1.5 RBTC 1 Single tx boundary
just above min 2.9 RBTC 1.5 RBTC 1 N=1 absorbs all
exact 2x 3.0 RBTC 1.5 RBTC 2 Zero remainder, exact split
just below 2x 2.9 RBTC 1.5 RBTC 1 Integer truncation keeps N=1
2x + 1 wei 3.0 RBTC + 1 wei 1.5 RBTC 2 Smallest possible remainder
large remainder 8.3 RBTC 1.5 RBTC 5 0.8 RBTC remainder in first chunk
exact 3x 4.5 RBTC 1.5 RBTC 3 Multi-tx zero remainder
100 RBTC / 66 txs 100 RBTC 1.5 RBTC 66 Large N with 1.0 RBTC remainder

Coverage Gaps (Minor)

  1. No test with multiple quotes and UTXO_SPLIT — all UTXO_SPLIT tests use a single quote or the shared bridgePegoutTestWatchedQuotes. A test with many quotes where Value+CallFee+GasFee produces an interesting total would add confidence.
  2. No test for first tx failure in N≥2testUtxoSplitFailMidSplit tests tx2 failure. A test where tx1 itself fails would verify the early-exit path at lines 170-175.

7. Security Assessment

7.1 LPS Flow Security

Concern Assessment Risk
Fund safety All bridge transactions target contracts.Bridge.GetAddress() — hardcoded from contract registry, not user-controlled ✅ None
Double-send prevention rskWalletMutex serializes all bridge sends; quotes transition to terminal state (BridgeTxSucceeded/BridgeTxFailed) atomically ✅ None
Denial of service via config REBALANCE_STRATEGY validated at startup — invalid value prevents boot. No runtime mutation ✅ None
Integer overflow big.Int arithmetic — no overflow possible ✅ None
Rounding/loss of funds Sum invariant proven algebraically and verified by tests with wei-precision values ✅ None
Gas exhaustion Balance check includes N × gasPerTx before any send. If insufficient, rejects early ✅ None
Partial failure state Conservative: all quotes marked failed on any mid-split error. No funds are lost — successful bridge txs still produce BTC releases ⚠️ Low (accounting mismatch, not fund loss)
Strategy injection Double-validated (env validator + switch/case). Default falls back to ALL_AT_ONCE. No path to inject arbitrary strategy ✅ None

7.2 Rootstock Federation Bridge Flow Security

This section examines how the UTXO_SPLIT strategy affects the RSK Bridge and federation nodes.

Bridge Perspective

The RSK Bridge at 0x0000000000000000000000000000000001000006 processes incoming RBTC transfers as pegout requests. Each transfer that meets the minimum lock value is queued for BTC release.

Concern Analysis
Multiple small txs vs one large tx The Bridge treats each incoming transfer independently. Whether the LP sends 1×4.5 RBTC or 3×1.5 RBTC, the Bridge queues them separately. This is standard Bridge behavior — no new attack surface.
Bridge minimum enforcement Each UTXO_SPLIT transaction sends exactly bridgeMin (or bridgeMin + remainder for the first). Since bridgeMin is configured to match BridgeTransactionMin (the Bridge's own minimum), all txs meet the Bridge's acceptance threshold.
Pegout queue congestion UTXO_SPLIT creates N pegout queue entries instead of 1. On mainnet, the Bridge batches pegouts per block interval (getNextPegoutCreationBlockNumber). More queue entries mean more BTC outputs in the release tx, increasing its size. For N≤10 this is negligible; for very large N (e.g., 66 in the 100 RBTC test case), the Bridge release tx could be significant.
Federation signing load Each queued pegout requires federation consensus for the BTC release. N pegouts in the same batch are signed together (single BTC tx with N outputs), so the signing overhead is per-batch, not per-pegout. No significant impact.

BTC Side

Concern Analysis
UTXO set for the LP This is the intended benefit — N UTXOs of ~bridgeMin BTC each, instead of 1 large UTXO. Improves the LP's ability to serve future pegout requests without change-output management.
BTC transaction fee for Bridge release A Bridge release tx with N outputs costs more in BTC fees than one with 1 output. The fee comes from the Bridge's fee-per-KB calculation (getFeePerKb). For typical N (2-5), this is minimal. The LP does not pay this fee — it's deducted from the Bridge's pool.
Dust outputs Not possible: each output is at least bridgeMin (1.5 RBTC = 1.5 BTC in regtest), far above Bitcoin's dust threshold (~546 satoshi).

Timing & Ordering

Concern Analysis
Nonce sequencing UTXO_SPLIT sends N transactions sequentially from the same RSK wallet. The rskWalletMutex ensures serialization. Each SendRbtc call should increment the nonce atomically. If the wallet implementation handles nonces correctly, this is safe.
Block inclusion N txs may land in the same RSK block or span multiple blocks. The Bridge processes them by block, so they may appear in the same or different pegout batches. This is acceptable — the Bridge handles this natively.
Race with other pegout cycles The watcher ticks every 5 minutes. The rskWalletMutex prevents concurrent bridge sends. A split of 66 txs at the gas price/limit used would complete well within 5 minutes. No race condition.

7.3 Attack Scenarios Considered

Attack Feasibility Mitigation
Attacker sets REBALANCE_STRATEGY to malicious value Requires access to the LPS container environment. Env validation rejects unknown values at startup. Double validation + startup failure
Attacker manipulates BridgeTransactionMin to force many small txs BridgeTransactionMin is set via authenticated Management API with CSRF protection. Not externally accessible. Authentication + CSRF
Attacker triggers excessive bridge congestion via UTXO_SPLIT LP operator controls their own strategy. An attacker cannot force the LP to use UTXO_SPLIT. The Bridge handles multiple pegout entries natively. Operator-only config
Front-running bridge txs during split Attacker could insert a tx between split txs. But the Bridge processes all qualifying transfers regardless of ordering. No exploit possible. Bridge is order-independent
Grief the LP with partial failure If the RSK network rejects mid-split txs (e.g., gas price spike), the LP marks all quotes as failed. LP funds from successful txs are not lost — they produce BTC UTXOs. The LP needs manual reconciliation. Conservative failure handling

7.4 Security Verdict

No security vulnerabilities identified. The feature is a clean extension of the existing rebalance flow. The UTXO_SPLIT strategy interacts with the Bridge in the same way as ALL_AT_ONCE — it simply sends multiple qualifying transfers instead of one. The Bridge's native handling of pegout queues, fee calculation, and federation signing is unaffected.

The one operational risk (partial failure accounting mismatch) is a design trade-off, not a vulnerability. It favors safety (no double-send) over convenience (manual reconciliation).


8. Compatibility with This Branch

The main feature commit was tested on master and then moved onto feature/FLY-2235. Key differences on this branch:

Branch Difference Impact on Rebalance Feature
LBC_ADDR replaced by split contract addresses None — rebalance uses contracts.Bridge, not LBC
FeeCollectorAddress removed None — rebalance does not reference fee collector
ProviderType changed from field to method None — rebalance does not reference provider type
AllowedOrigins CORS field added None — unrelated to use case layer
NewUseCaseRegistry return type change Correctly applied — returns (*UseCaseRegistry, error)

All unit tests pass on the branch (32 packages). Build compiles cleanly. No merge conflicts.

@volodymyrzahanych volodymyrzahanych requested a review from a team as a code owner March 13, 2026 13:15
@github-actions
Copy link

github-actions bot commented Mar 13, 2026

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA dcb1ba4.
Ensure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice.

OpenSSF Scorecard

PackageVersionScoreDetails

Scanned Files

    Introduce UTXO_SPLIT strategy that splits bridge conversions into
    multiple transactions based on BridgeTransactionMin, alongside the
    existing ALL_AT_ONCE behavior. Strategy is configured via
    REBALANCE_STRATEGY env variable.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a configurable rebalance strategy to the bridge pegout flow so operators can choose between the existing single-transaction rebalance and a split-into-many-transactions approach to improve BTC-side UTXO distribution.

Changes:

  • Introduces RebalanceStrategy (ALL_AT_ONCE, UTXO_SPLIT) and dispatches behavior in BridgePegoutUseCase.
  • Wires the strategy from environment → registry → use case, updating constructors and tests accordingly.
  • Adds unit tests covering parsing and UTXO split math/integrity.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
internal/usecases/pegout/bridge_pegout.go Implements strategy parsing + ALL_AT_ONCE vs UTXO_SPLIT execution paths and shared balance check.
internal/usecases/pegout/bridge_pegout_test.go Updates existing tests for new constructor signature; adds parsing and split strategy test coverage.
internal/configuration/environment/environment.go Adds REBALANCE_STRATEGY env field with validation.
internal/configuration/environment/environment_reader_test.go Ensures test env setup includes REBALANCE_STRATEGY.
internal/configuration/registry/usecase.go Parses strategy at startup and injects it into the bridge pegout use case; constructor now returns (*UseCaseRegistry, error).
internal/configuration/registry/usecase_test.go Updates to handle NewUseCaseRegistry returning an error and sets strategy in env.
internal/configuration/registry/watcher_test.go Updates to handle NewUseCaseRegistry returning an error and sets strategy in env.
internal/adapters/entrypoints/watcher/pegout_bridge_watcher_test.go Updates use case construction to pass default strategy.
cmd/application/lps/application.go Handles NewUseCaseRegistry returning an error at application startup.
docker-compose/local/docker-compose.lps.yml Exposes REBALANCE_STRATEGY to the container environment.
sample-config.env Adds REBALANCE_STRATEGY=ALL_AT_ONCE example/default.

You can also share your feedback on Copilot code review. Take the survey.

@Luisfc68
Copy link
Collaborator

@volodymyrzahanych one comment I forgot: there are multiple compose files for the LPS, pls add the env var to all of them, right now is just in the local. Thanks!

@volodymyrzahanych volodymyrzahanych force-pushed the feature/FLY-2235 branch 2 times, most recently from 38862d2 to d0088bc Compare March 24, 2026 07:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants